<?php

/**
 * Cache engine in km-style:
 *
 * you can set callbacks for getting, setting & deleting values in cache;
 * arguments for the callbacks as follows.
 * - getter: $id string cache key identifier
 * - setter: $id, $value, $lifetime
 * - deleter: $id
 *
 * km\Cache also supports output caching (f. e. full page caching):
 *
 * echo $cache->getBuffer($key, function() {
 * 		echo "some content";
 * });
 * // or
 * echo $cache->getBuffer($key, 'content-generating-function-name');
 * // or
 * echo $cache->getBuffer($key, array(<class name or object>, 'content-generating-method-name'));
 *
 * Cache->getBuffer() calls may be nested;
 * you may also use Cache->dynamicBuffer($callback, $args) to ensure some parts of your page
 * are never cached but generated on each request using $callback($arg1, $arg2,..). However,
 * when using Cache->dynamicBuffer() - the developer MUST TAKE CARE of the following:
 * * * * * * * * * * * * * * * * * * * * * * *
 * $callback and $args MUST be serializable! *
 * * * * * * * * * * * * * * * * * * * * * * *
 *  
 * @author vbolshov
 */

namespace km;

use \Km;
use \SplStack;
use km\cache\Buffer;

class Cache {
	const TAG_PREF = '__cachetag__';
	const BUFFER_PREF = '__cachebuffer__';
	/**
	 * Default lifetime of one hour
	 * @var int
	 */
	private $lifetime = 3600;
	/**
	 * @var Closure
	 */
	private $getter, $setter, $deleter, $cleaner;
	/**
	 * @var SplStack
	 */
	private $openBuffers;

	/**
	 * Get fullpage cache buffer.
	 * @param  $key
	 * @return string|bool
	 */
	function getBuffer($key, $renderer = null)
	{
		if ($buffer = $this->get(self::BUFFER_PREF . $key)) {
			return $buffer->render();
		} else {
			$this->openBuffer($key, $renderer);
			return $this->closeBuffer();
		}
	}
	/**
	 * Open fullpage caching buffer
	 * @param  $key
	 * @return bool always FALSE
	 */
	function openBuffer($key, $renderer)
	{
		$newBuffer = new Buffer(self::BUFFER_PREF . $key);
		if ($this->getOpenBuffers()->count()) {
			$this->dynamicBuffer(array($newBuffer, 'render'));
		}
		$this->getOpenBuffers()->push($newBuffer);
		ob_start();
		call_user_func($renderer);
		return false;
	}
	/**
	 * Close [a previously opened] fullpage caching buffer
	 * @return string
	 */
	function closeBuffer()
	{
		$this->assertOpenBuffer();
		
		$bufferString = ob_get_clean();
		$buffer = $this->getOpenBuffers()->pop();
		$buffer->addStatic($bufferString);
		$this->put($buffer->getName(), $buffer);

		return $buffer->render();
	}
	/**
	 * Insert a dynamic block into the current fullpage caching buffer
	 * @param  $callback
	 * @param array $args
	 * @return void
	 */
	function dynamicBuffer($callback, $args = array())
	{
		$this->assertOpenBuffer();
		$buffer = $this->getOpenBuffers()->top();
		$buffer->addStatic(ob_get_contents());
		ob_clean();
		$buffer->addCallback($callback, $args);
	}

	private function assertOpenBuffer()
	{
		if (! $this->getOpenBuffers()->count()) {
			throw new RuntimeException('No open buffers');
		}
	}

	/**
	 * @return SplStack
	 */
	private function getOpenBuffers()
	{
		return (null === $this->openBuffers) ? ($this->openBuffers = new SplStack()) : $this->openBuffers; 
	}

	/**
	 * Get cached value
	 *
	 * @param string $id
	 */
	function get($id)
	{
		return $this->getter->__invoke($id);
	}
	/**
	 * Get value from cache or, if that failed, call $retreiver callback to
	 * get it, then store in cache.
	 *
	 * @param string $id
	 * @param Closure $retreiver
	 * @param string $tag
	 * @param int $lifetime
	 */
	function getStore($id, $retreiver, $tag = null, $lifetime = null)
	{
		if (! $value = $this->get($id))
		{
			$value = Km::toClosure($retreiver)->__invoke();
			$this->put($id, $value, $tag, $lifetime);
		}
		return $value;
	}
	/**
	 * Put value in cache
	 *
	 * @param string $id
	 * @param mixed $value
	 * @param string $tag
	 * @param int $lifetime
	 */
	function put($id, $value, $tag = null, $lifetime = null)
	{
		$lifetime = $this->getLifetime($lifetime);
		$this->setter->__invoke($id, $value, $lifetime);
		if (null !== $tag)
		{
			$tag_cache_id = self::TAG_PREF . $tag;
			$tag_contents = $this->getTag($tag_cache_id);
			if (! in_array($id, $tag_contents))
			{
				$tag_contents[] = $id;
				$this->put($tag_cache_id, $tag_contents, null, $lifetime);
			}
		}
	}
	/**
	 * Delete value
	 *
	 * @param string $id
	 */
	function delete($id)
	{
		return $this->deleter->__invoke($id);
	}
	/**
	 * Cleanup values tagged with $tag
	 *
	 * @param string $tag
	 */
	function cleanup($tag)
	{
		foreach ($this->getTag(self::TAG_PREF . $tag) as $id) {
			$this->delete($id);
		}
		$this->put(self::TAG_PREF . $tag, array(), null);
	}

	function deleteAll()
	{
		return $this->cleaner ?
				$this->cleaner->__invoke() :
				false;
	}

	/**
	 * setup callbacks
	 *
	 * @param Closure $getter
	 * @param Closure $setter
	 * @param Closure $deleter
	 * @param Closure $cleaner
	 */
	function setup($getter, $setter, $deleter, $cleaner = null)
	{
		$this->getter = Km::toClosure($getter);
		$this->setter = Km::toClosure($setter);
		$this->deleter = Km::toClosure($deleter);
		$this->cleaner = $cleaner ? Km::toClosure($cleaner) : null;
	}
	/**
	 * Setup callbacks using class or object methods
	 * @param  $classOrObject
	 * @param string $getter
	 * @param string $setter
	 * @param string $deleter
	 * @return void
	 */
	function setBackend($classOrObject, $getter = 'get', $setter = 'put', $deleter = 'delete')
	{
		return $this->setup(
			array($class, $getter),
			array($class, $setter),
			array($class, $deleter)
		);
	}
	/**
	 * @param int $lifetime
	 */
	function setLifetime($lifetime)
	{
		$this->lifetime = $lifetime;
	}
	/**
	 * @param int $override
	 */
	private function getLifetime($override)
	{
		return (null === $override) ? $this->lifetime : $override;
	}
	/**
	 * Get cache ids tagged with tag which cache id is $tag_cache_id
	 * @param $tag_cache_id
	 * @return string[]
	 */
	private function getTag($tag_cache_id)
	{
		if ($tag_contents = $this->get($tag_cache_id))
		{
			return $tag_contents;
		} else {
			return array();
		}
	}
}